A detailed backend reference

Menguasai Konkurensi & Paralelisme

Setiap sistem backend yang akan Anda bangun berbagi satu persyaratan yang tidak bisa ditawar: ia harus menangani banyak hal sekaligus. Web server yang hanya bisa memproses satu request per waktu memaksa setiap pengguna lain menunggu, dan itu tidak akan berhasil di produksi. Panduan ini membangun model mental lengkap dari dasar, dari siklus CPU mentah dan operasi IO, lewat thread OS dan event loop, sampai primitif konkurensi modern seperti goroutine dan async/await. Semuanya berpijak pada kode backend praktis memakai Go dan Python.


Versi Sederhana

01

Kenapa Konkurensi Penting

Saat kita bicara "aplikasi backend" dalam konteks ini, maksudnya web server berbasis HTTP, proses yang berjalan di mesin, mendengarkan di port, menerima request dari browser dan klien lain, dan mengirim respons kembali.

Jika server Anda hanya bisa memproses satu request per waktu, maka setiap pengguna lain, berpotensi ribuan, harus menunggu dalam antrean atau menerima error. Situasi mustahil untuk aplikasi produksi nyata mana pun.

Key Insight

Memahami bagaimana server Anda melakukan banyak hal sekaligus bukan sekadar "bagus untuk diketahui", ia membentuk cara Anda men-debug masalah, menyusun aplikasi, dan membuat keputusan arsitektural.

Kebanyakan kita mempelajari kata kunci seperti async, await, dan threading dari dokumentasi bahasa pemrograman. Kita tahu mereka "membuat hal menjadi konkuren." Tapi kita jarang memahami apa yang sebenarnya dilakukan kata kunci ini di balik layar, di level mekanis, apa yang terjadi di CPU dan sistem operasi.


02

Biaya Duduk Menganggur

Mari mulai dengan skenario konkret. Anda punya browser membuat request ke backend. Backend perlu meng-query database, menunggu respons, dan mengembalikan data ke klien. Itu siklus hidup khas panggilan API.

Berapa lama query database?

Waktu tunggu tergantung di mana database Anda tinggal relatif terhadap server Anda:

Skenario Latensi Deskripsi
Localhost 1-2 ms Server dan database di mesin yang sama
Region sama, AZ berbeda 20-30 ms Setup produksi khas (mis. AWS us-east-1a -> us-east-1b)
Lintas region 90-100 ms Database di region yang jauh secara geografis

Selama waktu tunggu itu, entah 2 ms atau 100 ms, jika server Anda memproses secara sinkron (satu request per waktu, baris demi baris), CPU Anda melakukan sama sekali tidak ada apa pun. Ia sepenuhnya idle.

Mengukur pemborosan

CPU modern mengeksekusi ±3 miliar instruksi per detik, sekitar 3 juta instruksi per milidetik. Jika server Anda duduk idle 100 ms menunggu respons database, ia seharusnya bisa mengeksekusi 300 juta instruksi: tapi malah mengeksekusi nol.

SYNCHRONOUS SERVER, SINGLE REQUEST CPU IDLE, waiting for DB response (100 ms) CPU CPU utilization: ~5% Wasted potential: 300 million instructions Resources idle: 95% of the time
Fig 1: A synchronous server wastes the vast majority of its CPU cycles waiting for IO

Uraian panggilan API yang realistis

Panggilan API menengah-ke-kompleks khas melibatkan 3-5 query database plus 1-2 panggilan layanan eksternal (email, cache seperti Redis, dll.). Jika masing-masing dari lima operasi jaringan itu rata-rata 50 ms, total waktu menunggu IO ±250 ms. Tapi komputasi CPU aktual, validasi, parsing JSON, serialisasi, hanya ±10 ms. Artinya perangkat keras server Anda idle 95% dari waktu. Persis inilah masalah yang dipecahkan konkurensi.


03

IO-Bound vs CPU-Bound

Setiap operasi yang dilakukan backend Anda jatuh ke salah satu dari dua kategori. Perbedaan ini konsep terpenting di seluruh panduan ini, ia menggerakkan setiap keputusan tentang model konkurensi mana yang dipakai.

Operasi IO-Bound

IO singkatan Input/Output: operasi apa pun tempat CPU tidak bisa melakukan hal berguna karena menunggu sesuatu yang eksternal. "I" merujuk data yang datang ke dalam proses Anda; "O" merujuk data yang keluar. Menurut halaman manual Linux read(2), read pada file descriptor (socket, file, pipe) akan memblokir thread pemanggil sampai data tiba.

Operasi IO-bound umum di sistem backend:

  • Query database: mengirim query SQL lewat TCP, menunggu baris kembali
  • Panggilan API eksternal: request HTTP ke layanan pihak ketiga (payment gateway, penyedia email)
  • Operasi file system: membaca file config, menulis upload, penanganan file sementara
  • Interaksi cache: operasi GET/SET Redis lewat jaringan
  • I/O standar: logging ke stdout, membaca dari stdin
  • Message queue: publish ke atau consume dari Kafka, RabbitMQ, dll.

Operasi CPU-Bound

Kerja CPU-bound saat prosesor aktif mengunyah data, ia mengeksekusi instruksi secepat mungkin, dan bottleneck-nya CPU itu sendiri, bukan resource eksternal apa pun. Kebanyakan kerja CPU-bound di backend khas ringan (1-2 ms untuk parsing JSON atau validasi), tapi sebagian operasi sangat CPU-bound:

  • Pemrosesan gambar/video: perkalian matriks, konvolusi, transformasi ruang warna
  • Enkripsi / hashing: verifikasi JWT, hashing kata sandi bcrypt, handshake TLS
  • Kompresi: encoding gzip, zstd, Brotli dari body respons
  • Serialisasi: marshal/unmarshal JSON/Protobuf skala besar
  • Inferensi ML: menjalankan forward pass model tanpa offload GPU
The Rule of Thumb

Lebih dari 70% waktu di aplikasi backend khas dihabiskan untuk IO. Untuk kerja IO-bound, konkurensi wajib. Untuk kerja CPU-bound, paralelisme (banyak core) memberi percepatan.

TYPICAL API CALL, TIME BREAKDOWN DB query 1 DB query 2 Redis cache Email API CPU work (~10 ms, ~5%) IO wait (~250 ms, ~95%) Total wall-clock time: ~260 ms Actual CPU usage: ~10 ms, the rest is just waiting
Fig 2: CPU work (red) is dwarfed by IO waiting (orange) in a typical API call

04

Konkurensi vs Paralelisme

Kedua istilah ini sering dikacaukan, dan dengan alasan yang baik, karena keduanya melibatkan "melakukan banyak hal." Tapi perbedaannya kritis.

Paralelisme: melakukan banyak hal pada waktu yang sama

Sebuah program paralel saat ia mengeksekusi banyak instruksi di momen yang sama. Ini butuh dukungan perangkat keras: minimal dua core CPU, karena satu core hanya bisa mengeksekusi satu instruksi dalam satu instan. Jika Anda punya 4 core, Anda bisa menjalankan 4 instruksi benar-benar serentak.

Konkurensi: menangani banyak hal sekaligus

Konkurensi soal menyusun program Anda supaya banyak tugas bisa dimulai, dijeda, dan dilanjutkan, bahkan di satu core. Dari luar, tampak semuanya terjadi sekaligus. Tapi perbesar ke momen tunggal mana pun, dan hanya satu hal yang sebenarnya mengeksekusi di core tertentu. Kuncinya: saat satu tugas terblokir (menunggu IO), tugas lain mendapat CPU.

The Classic One-Liner

Paralelisme soal melakukan banyak hal sekaligus. Konkurensi soal menangani banyak hal sekaligus.

Dikreditkan ke Rob Pike, salah satu pencipta Go

CONCURRENCY (1 CORE) vs PARALLELISM (2 CORES) 0ms 10 20 30 40 50 60 CONCURRENT (1 core) Req A CPU IO wait CPU Req B CPU IO wait CPU A pauses -> B gets CPU PARALLEL (2 cores) Req A CPU CPU Req B CPU CPU Both start on separate cores simultaneously
Fig 3: Concurrency interleaves tasks on one core; Parallelism runs them on separate cores simultaneously

05

Thread OS

Mekanisme fundamental pertama untuk melakukan banyak hal sekaligus adalah thread. Thread unit eksekusi independen yang dikelola sistem operasi. Ia bukan fitur bahasa pemrograman Anda, ia fitur kernel OS. Saat Anda "membuat thread", Anda membuat system call (seperti clone() di Linux atau pthread_create() via POSIX) untuk meminta kernel menyiapkan konteks eksekusi baru.

Apa yang OS buat untuk tiap thread

Saat thread baru dibuat, kernel mengalokasikan beberapa hal untuknya:

  • Stack: dipakai melacak panggilan fungsi (call frame), variabel lokal, dan alamat kembali. Di Linux, ukuran stack default 8 MB memori virtual (meski halaman fisik hanya dialokasikan sesuai permintaan via page fault).
  • Instruction pointer (IP): melacak persis di mana thread berada di kodenya, supaya kernel bisa melanjutkannya setelah context switch.
  • Struktur data kernel: metadata untuk scheduler: state thread (running / sleeping / blocked), prioritas, CPU affinity, signal mask, dll.

Scheduling preemptive

Scheduler OS memutuskan thread mana yang mendapat waktu CPU dan berapa lama. Ia menetapkan tiap thread time slice (biasanya beberapa milidetik). Setelah slice itu kedaluwarsa, scheduler secara paksa menjeda thread, entah selesai atau tidak, menyimpan state-nya, dan beralih ke thread lain. Ini disebut preemptive scheduling karena thread di-preempt (diinterupsi) tanpa kerja sama mereka.

Memori bersama antar thread

Sifat penting: thread dalam proses yang sama berbagi ruang memori yang sama (heap, variabel global, file descriptor). Jika thread 1 mengalokasikan objek di heap, thread 2 bisa mengaksesnya via pointer. Ini membuat komunikasi antar-thread cepat (tanpa salinan, tanpa serialisasi), tapi juga berbahaya, seperti yang akan kita lihat di bagian race condition.

Thread lintas proses berbeda, bagaimanapun, terisolasi penuh, mereka tidak bisa melihat memori satu sama lain. Isolasi ini ditegakkan sistem memori virtual kernel demi keamanan dan stabilitas.

Thread-per-request di Python

# Python, threading model (simplified)
import threading

def handle_request(conn):
    # 1. Parse the HTTP request (CPU work)
    request = parse_http(conn)

    # 2. Database query, this BLOCKS the thread
    #    The OS scheduler will switch to another thread
    user = db.execute(
        "SELECT * FROM users WHERE id = %s",
        (request.user_id,)
    )

    # 3. Thread resumes here after DB responds
    response = json.dumps(user)
    conn.send(response)

# Create a new OS thread for each incoming connection
while True:
    conn = server_socket.accept()
    t = threading.Thread(target=handle_request, args=(conn,))
    t.start()Python

Perhatikan tidak ada async atau await di mana pun. Blocking terjadi transparan, di level OS, saat db.execute() membuat panggilan jaringan. Thread ditandai "blocked", OS beralih ke thread lain, dan belakangan membangunkan thread ini saat respons database tiba di socket.


06

Overhead Thread & Context Switching

Thread kuat, tapi mereka datang dengan tiga kategori overhead yang membatasi berapa banyak yang praktis bisa Anda jalankan.

1. Overhead memori

Tiap thread mendapat stack sendiri. Di Linux default 8 MB ruang alamat virtual (meski hanya halaman yang disentuh yang mengonsumsi RAM fisik). Bahkan dengan konservatif 500 KB-1 MB memori fisik aktual per thread, 10.000 koneksi serentak akan mengonsumsi 5-10 GB RAM hanya untuk stack thread, sebelum aplikasi Anda bahkan mengalokasikan satu struktur data pun.

2. Overhead pembuatan

Membuat thread butuh system call ke kernel. Kernel harus mengalokasikan stack, menyiapkan struktur data internal (deskriptor thread, state sinyal, metadata scheduler), dan mendaftarkan thread ke scheduler. Ini makan dari beberapa mikrodetik sampai beberapa milidetik: cepat menurut standar manusia, tapi signifikan di skala saat Anda membuat dan menghancurkan ribuan thread.

3. Overhead context switch

Ini yang besar. Setiap kali scheduler beralih dari satu thread ke yang lain, ia harus:

  1. Simpan register CPU thread saat ini (serbaguna, floating-point, SIMD)
  2. Perbarui struktur pembukuan (state thread, pencatatan waktu)
  3. Pilih thread berikutnya untuk dijalankan (melibatkan algoritma scheduler)
  4. Pulihkan register tersimpan thread baru
  5. Berpotensi membuang cache CPU (baris cache L1/L2 jadi "dingin")

Setiap context switch makan 1-10 mikrodetik di perangkat keras modern. Dengan 4 core dan 1.000 thread, scheduler harus terus menerus mengatur di antara mereka, dan overhead ini bisa menjadi sumber latensi yang terukur. Waktu context switch murni tidak produktif, ia kerja pemeliharaan, bukan komputasi nyata.

CONTEXT SWITCH BETWEEN THREADS Thread 1 executing CONTEXT SWITCH 1-10 us Thread 2 executing SWITCH Thread 3 During a context switch the OS must: (1) Save CPU registers & instruction pointer (2) Update scheduler bookkeeping (3) Restore next thread's registers & state (4) Potentially invalidate CPU cache lines
Fig 4: Context switching is pure overhead: no useful work happens during the switch
Why thread-per-request struggles

Persis inilah kenapa model "satu thread per request" hancur di bawah konkurensi tinggi. 10.000 koneksi serentak = 10.000 thread = gigabyte memori stack + overhead context switching konstan. Masalah C10k adalah realisasi industri bahwa model ini tidak berskala.


07

Model Event Loop

Event loop mengambil pendekatan radikal berbeda untuk konkurensi: alih-alih banyak thread, ia memakai satu thread dan loop yang terus memeriksa operasi IO yang selesai, lalu menjalankan callback yang sesuai. Ini model yang menggerakkan Node.js, asyncio Python, Nginx, dan banyak sistem berperforma tinggi lain.

Mekanisme inti

Saat tugas (seperti melayani Request A) perlu membuat query database, alih-alih memblokir dan menunggu, ia:

  1. Memulai IO: mengirim byte query lewat socket TCP ke database.
  2. Mendaftarkan callback: memberi tahu event loop: "Saat database merespons di socket ini, jalankan fungsi ini."
  3. Melepaskan kendali: kembali seketika, membebaskan thread tunggal untuk mengambil tugas berikutnya.

Event loop itu sendiri pada dasarnya loop tak hingga. Di tiap iterasi, ia memakai mekanisme level OS untuk memeriksa socket mana yang datanya siap, lalu menjalankan callback untuk operasi selesai itu dan kembali memeriksa.

Multiplexing IO level OS

Event loop tidak mengimplementasikan pemantauan IO sendiri, ia mengandalkan sistem operasi. OS menyediakan system call khusus yang bisa memantau secara efisien ribuan file descriptor (socket, file, pipe) sekaligus:

OS Mekanisme Catatan
Linux epoll O(1) per peristiwa siap. Berskala ke jutaan koneksi. Lihat epoll(7)
macOS / BSD kqueue Mirip epoll tapi API berbeda. Lihat kqueue(2)
Windows IOCP IO Completion Ports, padanan Windows

Di balik layar, Node.js memakai libuv yang mengabstraksikan mekanisme spesifik platform ini. asyncio Python memakai modul selectors yang membungkus epoll/kqueue.

EVENT LOOP, SINGLE-THREADED CONCURRENCY Event Loop (single thread) Request A Request B Request C DB query (waiting...) Redis GET (waiting...) Email API (waiting...) DB response READY run callback Monitored by epoll / kqueue Each iteration: check IO completions -> run callbacks -> repeat
Fig 5: The event loop checks IO status on each iteration and runs callbacks for completed operations

Kenapa event loop efisien untuk IO

Karena hanya ada satu thread, tanpa overhead context switching, tanpa alokasi stack multi-megabyte, dan tanpa juggling scheduler. CPU menghabiskan waktunya entah menjalankan kode aktual Anda (callback) atau menunggu efisien di dalam epoll_wait(). Inilah kenapa Node.js bisa menangani puluhan ribu koneksi serentak di perangkat keras sederhana.

Kompromi kritis: jangan pernah memblokir loop

Jika Anda menjalankan operasi intensif CPU, katakanlah tugas pemrosesan gambar 100 ms, langsung di thread event loop, semuanya berhenti. Tidak ada request lain yang bisa dilayani, tidak ada callback yang bisa menyala, tidak ada penyelesaian IO yang bisa diproses. Seluruh server membeku sampai kerja CPU itu selesai. Inilah kenapa bahasa berbasis event loop seperti JavaScript menuntut kode non-blocking dan menggeser komputasi berat ke worker thread atau proses terpisah.

Callback -> Promise -> async/await: evolusinya

Event loop bekerja pada callback, tapi callback mentah mengarah ke kode bersarang dalam ("callback hell" yang terkenal). Evolusinya di JavaScript:

// Stage 1: Raw callbacks (pre-ES6)
db.query("SELECT * FROM users WHERE id = ?", [userId],
  function(err, result) {
    if (err) return sendError(res, err);
    // Nested callback for second query
    db.query("SELECT * FROM orders WHERE user_id = ?", [userId],
      function(err, orders) {
        if (err) return sendError(res, err);
        // Another level of nesting...
        sendResponse(res, { user: result, orders: orders });
      }
    );
  }
);JavaScript / callback hell
// Stage 2: async/await (modern, syntactic sugar over callbacks)
async function handleRequest(req, res) {
  const user  = await db.query("SELECT * FROM users WHERE id = ?", [req.userId]);
  const orders = await db.query("SELECT * FROM orders WHERE user_id = ?", [req.userId]);
  sendResponse(res, { user, orders });
}JavaScript / async/await

Kata kunci await gula sintaksis. Saat runtime menjumpai await, ia berkata: "Aku melepaskan kendali CPU ke event loop. Semua setelah baris ini efektifnya callback yang akan berjalan saat operasi IO ini selesai."

asyncio Python: gagasan yang sama

import asyncio
import aiohttp

async def handle_request(user_id: int) -> dict:
    # await = "yield control to the event loop until this IO completes"
    user = await db.fetchone(
        "SELECT * FROM users WHERE id = $1", user_id
    )
    orders = await db.fetch(
        "SELECT * FROM orders WHERE user_id = $1", user_id
    )
    return {"user": user, "orders": orders}

# Run with an event loop (uvloop for performance)
asyncio.run(handle_request(123))Python

08

Async/Await sebagai State Machine

Untuk benar-benar memahami async/await, membantu melihat apa yang sebenarnya kompiler atau runtime ubah dari kode Anda. Fungsi async pada dasarnya dikonversi menjadi state machine yang berinteraksi dengan event loop. Setiap await titik transisi antar state.

Fungsi async aslinya

async def fetch_user_data(user_id):
    user   = await db.get_user(user_id)      # State 0 -> 1
    orders = await db.get_orders(user_id)    # State 1 -> 2
    return {"user": user, "orders": orders}  # State 2 -> donePython

Apa yang dilihat runtime (konseptual)

# Conceptual state machine representation
def fetch_user_data_machine(user_id):
    state = 0
    user = None
    orders = None

    def step():
        nonlocal state, user, orders

        if state == 0:
            state = 1
            # Start the IO, register callback for when it completes
            return db.get_user(user_id).then(
                lambda result: (user := result, step())
            )

        elif state == 1:
            state = 2
            return db.get_orders(user_id).then(
                lambda result: (orders := result, step())
            )

        elif state == 2:
            return {"user": user, "orders": orders}

    return stepPython / conceptual
ASYNC FUNCTION AS STATE MACHINE State 0 get_user() await event loop is free here State 1 get_orders() await event loop is free here State 2 return result Done Between each state, the event loop is free to execute other tasks
Fig 6, Each await is a state transition; the event loop runs other work in between

Dua hal yang dijelaskan ini

1. Kenapa await hanya bisa muncul di dalam fungsi async. Fungsi harus ditandai async karena ia perlu ditransformasi menjadi state machine. Fungsi biasa tidak punya infrastruktur state machine untuk mendukung jeda dan lanjut.

2. Kenapa memblokir event loop katastrofal. Jika Anda melakukan operasi intensif CPU di dalam state (mis. komputasi 500 ms), state machine tidak pernah bisa bertransisi. Tidak ada state machine lain (request lain) yang bisa maju. Seluruh server menggantung.


09

Goroutine Go & Virtual Thread

Go mengambil pendekatan ketiga yang bukan thread OS mentah maupun event loop satu thread. Ia memakai goroutine: thread ruang-pengguna yang ringan, dikelola scheduler runtime Go sendiri. Istilah "virtual thread" menangkap gagasannya: mereka berperilaku seperti thread (kode blocking, logika sekuensial), tapi jauh lebih murah karena bukan konstruk level OS.

Kenapa goroutine murah

Sifat Thread OS Goroutine
Ukuran stack ±8 MB (virtual), ±KB fisik ±2-8 KB awal (tumbuh dinamis)
Waktu pembuatan ±us sampai ms (system call) ±ns sampai us (alokasi ruang-pengguna)
Context switch 1-10 us (mode kernel) ±200 ns (tukar pointer ruang-pengguna)
Batas praktis ±10.000 (bottleneck memori) ±1.000.000+ (tergantung RAM)
Dijadwalkan oleh Kernel OS Scheduler runtime Go

Server HTTP Go: satu goroutine per request

Library standar Go net/http membuat goroutine baru untuk setiap request masuk. Ini terdengar seperti model thread-per-request yang barusan kita bilang tidak berskala, tapi goroutine begitu ringan sehingga bekerja dengan indah. Ini pola source code aktual dari paket net/http Go:

// From Go's standard library net/http/server.go (simplified)
func (srv *Server) Serve(l net.Listener) error {
    for {
        conn, err := l.Accept()   // Accept new TCP connection
        if err != nil {
            return err
        }
        // Create a NEW goroutine for each connection
        go srv.handleConn(conn)
    }
}Go

Kata kunci go men-spawn goroutine baru. Ini padanan membuat thread, tapi di sebagian kecil biayanya (nanodetik vs. mikrodetik, kilobyte vs. megabyte).

Handler Go khas

func handleGetUser(w http.ResponseWriter, r *http.Request) {
    userID := r.URL.Query().Get("id")

    // This BLOCKS the goroutine, but not the OS thread.
    // The Go scheduler parks this goroutine and runs another.
    user, err := db.QueryRow(
        "SELECT name, email FROM users WHERE id = $1",
        userID,
    )
    if err != nil {
        http.Error(w, "not found", http.StatusNotFound)
        return
    }

    // Goroutine resumes here after DB responds
    json.NewEncoder(w).Encode(user)
}Go

Perhatikan: tanpa async, tanpa await, tanpa callback. Kodenya terbaca seperti kode sinkron, blocking, karena goroutine memang memblokir. Tapi scheduler runtime Go secara transparan memarkirnya dan mengambil goroutine lain. Anda mendapat ergonomi kode blocking dengan efisiensi IO non-blocking.


10

Scheduler M:N

Scheduler Go memakai model M:N: ia memultipleks M goroutine ke N thread OS. Jumlah thread OS dikendalikan environment variable GOMAXPROCS (default jumlah core CPU). Tiap thread OS menjalankan antrean lokal goroutine.

GO'S M:N SCHEDULER, GOROUTINES MAPPED TO OS THREADS OS THREADS (GOMAXPROCS = 4) M1 (Core 1) M2 (Core 2) M3 (Core 3) M4 (Core 4) GOROUTINES (thousands possible) G1 G5 G9 G2 G6 G10 G3 G7 G4 G8 G11 Go Runtime Scheduler When G1 blocks on IO -> scheduler parks G1, runs G5 on same M1 thread Context switch cost: ~200ns (pointer swap), not ~10us (kernel mode)
Fig 7: Go multiplexes many goroutines across a fixed pool of OS threads

Saat goroutine memblokir pada IO (mis. baca database), network poller internal runtime Go (dibangun di atas epoll/kqueue di balik layar) mendeteksi penyelesaian IO dan menaruh goroutine kembali ke antrean run. Ini yang terbaik dari kedua dunia: kode gaya-blocking dengan efisiensi level event loop.


11

Race Condition & State Bersama

Konkurensi memperkenalkan seluruh kategori bug yang tidak ada di kode sekuensial. Hampir semuanya menelusuri kembali ke satu akar: state mutable bersama. Saat dua tugas konkuren membaca dan menulis variabel yang sama, dan operasinya saling menyisip dengan cara tak terduga, Anda mendapat race condition.

Contoh counter klasik

Dua thread sama-sama mencoba menaikkan counter bersama dari 0 ke 1. Menaikkan butuh tiga langkah CPU: baca nilai saat ini, tambah satu, tulis kembali. Jika thread saling menyisip:

RACE CONDITION, LOST UPDATE PROBLEM TIME THREAD A THREAD B counter t1 Read counter -> 0 0 t2 Read counter -> 0 0 t3 Add 1 -> register = 1 0 t4 Add 1 -> register = 1 0 t5 Write 1 -> counter 1 t6 Write 1 -> counter 1 Expected: 2 Actual: 1, Thread B's increment was lost!
Fig 8: Two threads read the same stale value, and one update overwrites the other

Race condition di async/await juga

Anda mungkin berpikir: "Aku memakai JavaScript / Python dengan async/await, hanya ada satu thread, jadi tidak ada race condition." Salah. Race condition masih bisa terjadi di antara titik await. Anggap contoh Python ini:

balance = 100

async def withdraw(amount: int):
    global balance
    if balance >= amount:               # Check at time T1
        await process_withdrawal(amount)  # <- yields control here!
        balance -= amount                 # Deduct at time T2

# Both coroutines see balance=100, both pass the check,
# both deduct 100 -> balance = -100 (invalid!)
await asyncio.gather(
    withdraw(100),
    withdraw(100),
)Python

Masalahnya: pemeriksaan if dan pengurangan dipisahkan oleh await. Di antara dua titik itu, event loop menjalankan panggilan withdraw kedua, yang juga melihat balance == 100 dan lolos pemeriksaan. Keduanya mengurangi, dan saldo jadi -100.

Key Takeaway

Race condition terjadi kapan pun ada pola check-then-act yang dipisahkan titik yield (await) atau context switch. Kode async satu thread tidak kebal terhadap race condition.


12

Lock, Mutex & Channel

Solusi untuk race condition telah dipelajari puluhan tahun. Ini pendekatan utamanya.

Mutex (Mutual Exclusion)

Mutex memastikan hanya satu thread/goroutine yang bisa mengeksekusi critical section pada satu waktu. Semua yang lain harus menunggu sampai lock dilepaskan. Ini primitif sinkronisasi paling umum di semua bahasa.

Python: threading.Lock

import threading

counter = 0
lock = threading.Lock()

def increment():
    global counter
    with lock:          # Acquire lock, blocks other threads
        counter += 1   # Only one thread executes this at a time
                        # Lock is released automatically at end of `with`

# Create 1000 threads all incrementing the same counter
threads = [threading.Thread(target=increment) for _ in range(1000)]
for t in threads: t.start()
for t in threads: t.join()
print(counter)  # Always 1000, no lost updatesPython

Go: sync.Mutex

package main

import (
    "fmt"
    "sync"
)

func main() {
    var counter int
    var mu sync.Mutex
    var wg sync.WaitGroup

    for i := 0; i < 1000; i++ {
        wg.Add(1)
        go func() {
            defer wg.Done()
            mu.Lock()         // Acquire, other goroutines wait here
            counter++
            mu.Unlock()       // Release, next goroutine can proceed
        }()
    }

    wg.Wait()
    fmt.Println(counter) // Always 1000
}Go

Channel: pendekatan pilihan Go

Go punya peribahasa terkenal: "Don't communicate by sharing memory; share memory by communicating." Alih-alih banyak goroutine menulis ke variabel bersama (yang butuh mutex), Anda bisa memakai channel: pipa bertipe lewat mana goroutine mengirim dan menerima nilai. Hanya goroutine yang memiliki data yang memodifikasinya; yang lain mengirim pesan untuk meminta perubahan.

package main

import "fmt"

func counterService(ch chan int, done chan bool) {
    counter := 0
    for {
        select {
        case delta := <-ch:
            counter += delta   // Only this goroutine modifies counter
        case done <- counter:
            return
        }
    }
}

func main() {
    ch := make(chan int, 100)     // Buffered channel
    done := make(chan bool)

    go counterService(ch, done)

    // 1000 goroutines send +1 through the channel
    for i := 0; i < 1000; i++ {
        ch <- 1
    }

    result := <-done
    fmt.Println(result) // 1000, no race condition, no mutex needed
}Go

Python asyncio.Lock untuk kode async

import asyncio

balance = 100
lock = asyncio.Lock()

async def withdraw(amount: int):
    global balance
    async with lock:                     # Only one coroutine at a time
        if balance >= amount:
            await process_withdrawal(amount)
            balance -= amount

# Now safe, second withdraw waits for the lock
await asyncio.gather(
    withdraw(100),
    withdraw(100),
)
print(balance)  # 0, not -100Python

13

Memilih Model yang Tepat

Ini ringkasan praktisnya, kapan memakai apa, dan kenapa.

Beban kerja Model Terbaik Kenapa Contoh
IO-Bound, konkurensi tinggi Event loop / async-await / goroutine Overhead minimal, tanpa context switching, menangani 100K+ koneksi Web server, API gateway, proxy microservice, server chat
CPU-Bound, bisa diparalelkan Thread OS dengan paralelisme (banyak core) Eksekusi serentak sejati; lebih banyak core = proporsional lebih cepat Pemrosesan gambar, encoding video, inferensi ML, enkripsi
Campuran IO + CPU Goroutine (Go) atau worker pool + async (Python/JS) IO ditangani konkuren; kerja CPU digeser ke pool thread/proses Kebanyakan backend dunia nyata, panggilan API + sedikit komputasi
The Bottom Line

Konkurensi menjaga program Anda produktif, ia menghentikan CPU menganggur sambil menunggu IO. Pakai async/await, goroutine, atau virtual thread untuk ini.

Paralelisme membuat Anda memakai banyak core CPU untuk melakukan banyak komputasi di instan yang sama. Pakai thread OS atau process pool untuk ini.

Kebanyakan aplikasi backend sangat IO-bound: bottleneck-nya hampir selalu jaringan, bukan CPU. Tapi saat Anda memang memukul kerja CPU-bound, paralelisme tool yang membuka lebih banyak throughput.

Primitif konkurensi spesifik bahasa

Bahasa Konkurensi IO Paralelisme CPU Mekanisme Dasar
Go go func() (goroutine) Goroutine lintas core (otomatis) Scheduler M:N + epoll/kqueue
Python asyncio + async/await multiprocessing / concurrent.futures Event loop + process pool (GIL membatasi thread)
JavaScript async/await / Promise worker_threads / cluster Event loop libuv
Java (21+) Virtual Thread (Thread.ofVirtual()) Platform Thread + ForkJoinPool Scheduler M:N berbasis continuation
Rust async/await + runtime Tokio std::thread / Rayon Runtime async multi-threaded work-stealing

14

Bacaan Lanjutan

Buku

  • Operating Systems: Three Easy Pieces oleh Remzi & Andrea Arpaci-Dusseau, standar emas untuk memahami thread, scheduling, dan konkurensi di level OS. Gratis online.
  • Concurrency in Go oleh Katherine Cox-Buday, selami dalam goroutine, channel, pola, dan jebakan.
  • Fluent Python, Bab 19-21 oleh Luciano Ramalho, konkurensi dengan thread, proses, dan asyncio.

Dokumentasi & artikel